Skip to content

Fix black captures from servers announcing DesktopSize before pixel data (#90) - #343

Merged
sibson merged 5 commits into
mainfrom
claude/fix-issue-90
Aug 18, 2026
Merged

Fix black captures from servers announcing DesktopSize before pixel data (#90)#343
sibson merged 5 commits into
mainfrom
claude/fix-issue-90

Conversation

@sibson

@sibson sibson commented Aug 13, 2026

Copy link
Copy Markdown
Owner

Fixes #90 — black screenshots against TightVNC on Windows, reported 2017–2025.

Mechanism (full analysis in the issue comment): TightVNC answers the first non-incremental FramebufferUpdateRequest with an update containing only the DesktopSize pseudo-rectangle. updateDesktopSize replaces self.screen with a black canvas, _doConnection fired commitUpdate unconditionally once the rectangle count hit zero, the pending capture completed, and the CLI saved the black canvas and exited — before the server's pixel frame (sent moments later) arrived.

The fix, two small parts:

  • rfb.py: the DesktopSize rect is removed from rectanglePos before decoding, exactly as the QEMU key-event pseudo-rect already is ("undo append as this is no real update") — pseudo-rectangles carrying no pixels no longer count as update content.
  • client.py: commitUpdate with a pending deferred and no content rectangles re-issues a non-incremental framebufferUpdateRequest and keeps waiting, instead of completing the refresh with nothing on the canvas.

A server that answered every non-incremental request with a content-free update could keep the client requesting until its timeout; real servers send content, and the per-client timeout still bounds the wait either way.

Regression tests live in their permanent home (tests/unit/test_client.py): a desktop-size-only update must not complete a refresh (and must re-request), and the refresh completes normally once pixel data arrives. Full unit suite green, flake8 clean.

End-to-end validation: the scripted server replaying TightVNC's logged behaviour (full repro kit in the issue thread) flips from a black capture (extrema ((0,0),(0,0),(0,0)), server logs could not send pixel data: CLIENT ALREADY DISCONNECTED) to the correct test pattern — after the DesktopSize rect the client now re-requests and receives the RAW frame before completing:

INFO:twisted:x=0 y=0 w=1024 h=640 <Encoding.PSEUDO_DESKTOP_SIZE: -223>
INFO:twisted:x=0 y=0 w=1024 h=640 <Encoding.RAW: 0>
INFO:twisted:Stopping factory <VNCDoCLIFactory ...>

Supersedes #342 (the test-only repro PR): this PR carries that test in final form, renamed for the behaviour it checks per the repo convention. When this merges, close #342 unmerged — its expectedFailure test would report an unexpected success on a fixed tree.

A framebuffer update whose only rectangle is the PSEUDO_DESKTOP_SIZE
pseudo-encoding used to complete a pending refresh: updateDesktopSize
replaces the screen with a black canvas and commitUpdate fired the
deferred regardless, so captureScreen saved the black canvas and the
CLI exited before the server's first pixel frame arrived. TightVNC on
Windows answers the first non-incremental update request exactly this
way, which is the black-screenshot report in #90 (2017-2025).

The DesktopSize rectangle is now excluded from the update's content
rectangles, matching the existing QEMU key-event pseudo-rect handling,
and a pending refresh whose update carried no content re-requests the
framebuffer instead of completing. Fixes #90.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread vncdotool/client.py

def commitUpdate(self, rectangles: list[rfb.Rect] | None = None) -> None:
if self.deferred:
if not rectangles:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should commitUpdate even get called if we are properly deleting the pseudo rectangle

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Claude Code: Still needs to be called — it's the extension point, not incidental plumbing. Proof: the base class already routes a genuinely-empty update (server sends rect count 0) through the same commitUpdate(self.rectanglePos) call in _doConnection, so "zero rectangles delivered" already meant "call commitUpdate anyway" before this fix existed. Moving the retry policy down into rfb.py instead would only patch the DesktopSize case and leave the QEMU-pseudo-rect-only case and a literal empty update still completing on nothing.

What is fixable: the append-then-delete on rectanglePos for the two pseudo-encodings is backwards — skip the append instead of adding and undoing it. Saving that as a follow-up rather than folding it into this PR.

sibson added 2 commits August 17, 2026 00:32
Issue references belong in the commit message and CHANGELOG, not
inline comments that outlive the issue.
tests/unit/test_issue_90.py landed on main via #342 after this branch
was cut; left in place it still carries @unittest.expectedFailure, so
once this fix lands the case flips to an unexpected success and
`make test` fails. Delete it per the triage-artifact convention now
that its behaviour is covered by the two tests this PR added to
test_client.py.

Also resolves the CHANGELOG.rst UNRELEASED-section conflict and adds
the missing @sibson attribution to the #90 entry.
@sibson

sibson commented Aug 18, 2026

Copy link
Copy Markdown
Owner Author

🤖 Claude Code: Verified and updated for merge.

Fix logiccommitUpdate (vncdotool/client.py) now re-requests the framebuffer instead of firing the capture deferred when a rectangle carries no pixel data, and rfb.py's _handleRectangle undoes the rectanglePos append for the DesktopSize pseudo-rectangle so it isn't counted as content. Matches the root cause identified in #90 and #342's repro.

Regression coverage — the two new tests in tests/unit/test_client.py (test_desktop_size_only_update_rerequests_instead_of_completing, test_refresh_completes_once_pixel_data_arrives) replay the DesktopSize-only update and confirm the refresh re-requests instead of completing, then completes once real pixel data arrives at the recorded size (1920x1200). Both pass.

Staleness fix — the branch was cut before #342 merged, so it didn't touch the triage artifact #342 added, tests/unit/test_issue_90.py (@unittest.expectedFailure). Left in place, this fix flips that case to UNEXPECTED SUCCESS and fails make test outright. I merged main into this branch, deleted that file per the repo's triage-artifact convention (behavior now lives in the topical test file above), resolved the CHANGELOG.rst conflict, and added the missing @sibson attribution to the #90 entry. Pushed as ce57385..98f479a.

Verification, from a worktree off the updated branch:

  • make test — 181 tests, OK (expected failures=2) (the 2 remaining expected failures are unrelated pre-existing cases)
  • flake8 --count --statistics vncdotool tests — 0
  • PR mergeability against main: MERGEABLE

Not merging or approving — leaving that to @sibson.

Both comments asserted what TightVNC does with no citation --
motivation for the fix, not a fact the code needs. The repo's own
convention already strips issue/PR references from code comments, so
there's nowhere to cite it from; drop the claim and keep the parts
grounded in this file: what a pixel-free update carries, and why
completing the refresh on one would hand back a blank canvas.
@sibson

sibson commented Aug 18, 2026

Copy link
Copy Markdown
Owner Author

🤖 Claude Code: Ran a comment-quality sweep (writing-code-comments skill) over the diff and pushed a follow-up (079a920).

Two comments asserted "TightVNC answers the first update request this way" with no citation — real motivation for the fix, but unsourced in place, and the repo's own convention already strips issue/PR refs from code comments so there's nowhere to cite it from. Trimmed both down to the parts grounded in this file: what a pixel-free update carries, and why completing the refresh on one hands back a blank canvas. The rfb.py bookkeeping comment and the CHANGELOG's TightVNC mention (which is the right place for it) were left as-is.

Re-verified after the change: make test — 181 tests, OK; flake8 — 0.

Comment thread vncdotool/client.py Outdated
if self.deferred:
if not rectangles:
# A pixel-free update, e.g. only a DesktopSize pseudo-rectangle.
# Completing the refresh now would hand over the bare black

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cleanaup ths comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Claude Code: Trimmed further (838350d) — dropped the DesktopSize example and the "black canvas" framing, which only held on first connect (updateDesktopSize pastes the prior screen back in on a resize, so it is not always black). Now: "No rectangle in this update painted self.screen; wait for one that does before completing the refresh."

Drop the DesktopSize example and the "black canvas" framing -- the
latter only holds on first connect (updateDesktopSize pastes prior
content back in on a resize), so it overclaimed for the general case.
State the actual guard instead: nothing in this update painted the
screen.
@sibson
sibson merged commit 82eefb9 into main Aug 18, 2026
10 of 11 checks passed
@sibson
sibson deleted the claude/fix-issue-90 branch August 18, 2026 11:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

always got black picture

2 participants